/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package io.milton.zsync;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;


import io.milton.http.Range;

/**
 * An object that performs the client-side operations needed to generate ZSync
 * PUT data.<p/>
 * In order to update a file on a server, the client first needs to download the
 * appropriate .zsync containing the metadata of the server file. The client
 * should then instantiate an
 * <code>UploadMaker</code>, passing to the constructor this .zsync file as well
 * as the local file to be uploaded. On construction, the
 * <code>UploadMaker</code> will determine the data ranges and assembly
 * instructions that need to be sent to the server, and will automatically fill
 * in an internal
 * <code>Upload</code> object. The client can then invoke the
 * <code>getInputStream</code> method, which will return a stream which should
 * be used as the body of a PUT request.<p/>
 *
 * E.g:
 * <p/>
 * <
 * code>
 *
 * UploadMaker um = new UploadMaker(File clientFile, File zsFile);<br/>
 * InputStream putData = um.getInputStream();
 * <p/>
 *
 * </code>
 *
 * Note: This is one of two classes that can be used to create a ZSync upload.
 * The other class,
 * <code>UploadMakerEx</code>, performs the same functions but may perform
 * better for certain rare cases.
 *
 *
 * @author Nick
 *
 * @see {@link Upload}, {@link UploadReader}, {@link UploadMakerEx}
 */
public class UploadMaker {

    /**
     * The local file that will replace the server file
     */
    public final File localCopy;
    /**
     * The .zsync of the server file to be replaced
     */
    public final File serversMetafile;
    private MetaFileReader metaFileReader;
    private MakeContext makeContext;
    private final Upload upload;

    /**
     * Constructor that automatically creates and fills in an internal upload
     * object.
     *
     * @param sourceFile The local file to be uploaded
     * @param destMeta The zsync of the server's file
     * @throws IOException
     */
    public UploadMaker(File sourceFile, File destMeta) throws IOException {

        this.localCopy = sourceFile;
        this.serversMetafile = destMeta;
        this.upload = new Upload();
    }

    public InputStream makeUpload() throws IOException {

        this.initMetaData();

        try {

            System.out.print("Matching client and server blocks...");
            long t0 = System.currentTimeMillis();

            /* Rolling checksum procedure */
            MapMatcher matcher = new MapMatcher();
            matcher.mapMatcher(localCopy, metaFileReader, makeContext);
            long t1 = System.currentTimeMillis();

//			System.out.println( " " + ( t1 - t0 ) + " milliseconds" );
//			System.out.print( "Creating Upload..." );
            long t2 = System.currentTimeMillis();

            /* Computing upload and writing to BufferingOutputStreams */
            this.initUpload();
            long t3 = System.currentTimeMillis();

//			System.out.println(" " + ( t3 - t2 ) + " milliseconds");

            return upload.getInputStream();
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }

    public void init() throws IOException {

        initMetaData();
        initUpload();
    }

    /**
     * Initializes MetaFileReader and MakeContext objects from the .zsync file
     * and finds and maps the matching blocks.
     *
     */
    private void initMetaData() {

        metaFileReader = new MetaFileReader(serversMetafile);
        makeContext = new MakeContext(metaFileReader.getHashtable(),
                new long[metaFileReader.getBlockCount()]);
        Arrays.fill(makeContext.fileMap, -1);

        //MapMatcher matcher = new MapMatcher();
        //matcher.mapMatcher( localCopy, metaFileReader, makeContext );

    }

    /**
     * Invokes the sequence of methods to generate the upload data and fill in
     * the internal Upload object.
     *
     * @throws IOException
     */
    private void initUpload() throws IOException {

        InputStream ranges = serversMissingRanges(makeContext.fileMap,
                localCopy, metaFileReader.getBlocksize());

        InputStream relocRanges = serversRelocationRanges(makeContext.fileMap,
                metaFileReader.getBlocksize(), localCopy.length(), true);

        upload.setVersion("testVersion");
        upload.setBlocksize(metaFileReader.getBlocksize());
        upload.setFilelength(localCopy.length());
        upload.setSha1(new SHA1(localCopy).SHA1sum());

        upload.setRelocStream(relocRanges);
        upload.setDataStream(ranges);
    }

    /**
     * Determines the byte ranges of new data that need to be sent to the server
     * to update its file.<p/>
     *
     * The
     * <code>fileMap</code> argument should be an array that maps matching
     * blocks from the server's file (the side that sent the metadata) to those
     * in the client file, such that
     * <code>fileMap[seq] == off</code> means that block number
     * <code>seq</code> in the server's file matches the block in the local file
     * beginning at byte
     * <code>off</code>. An invalid offset is ignored and should be used to
     * indicate that the local file contains no match for that block. The
     * <code>fileMap</code> array can be obtained from the MakeContext
     * class.<p/>
     *
     * @param fileMap An array mapping blocks in server file to their offsets in
     * local file
     * @param fileLength The length of the local file to be uploaded
     * @param blockSize The size of a block. Must correspond to block size used
     * in <code>fileMap</code>
     *
     * @return The List of byte Ranges that need to be sent
     * @throws IOException
     */
    public static InputStream serversMissingRanges(long[] fileMap,
            File local, int blockSize) throws IOException {

        /*
         * The ranges are determined by sorting the offset values in the fileMap array, 
         * i.e. sorting matching blocks according to their start byte in the local file. The method checks
         * the space between consecutive blocks, and if it is >= 0, adds that space to the list of ranges.
         */

        LinkedList<Long> localOffsets = new LinkedList<>(); // List of local matching block offsets
        //ArrayList<Range> rangeList = new ArrayList<Range>(); // output List
        ByteRangeWriter rangeList = new ByteRangeWriter(16384);
        RandomAccessFile randAccess = null;

        long fileLength = local.length();

        try {

            randAccess = new RandomAccessFile(local, "r");

            for (long offset : fileMap) {
                if (offset > -1 && offset < fileLength - blockSize) {
                    localOffsets.add(offset);
                }
            }

            localOffsets.add(fileLength); //Marks the end of the file
            Collections.sort(localOffsets); //Sort the blocks by their local offsets

            //Remove duplicate offsets
            Long prev = null;
            for (ListIterator<Long> iter = localOffsets.listIterator(); iter.hasNext();) {

                Long curr = iter.next();
                if (prev != null && curr.equals(prev)) {

                    iter.remove();
                } else {
                    prev = curr;
                }
            }

            /*Add the Range between the end of the previous block and the start of the 
             * current one, if that Range is > 0
             */
            long prevEnd = 0;
            for (Long offset : localOffsets) {
                if (offset - prevEnd > 0) {
                    rangeList.add(new Range(prevEnd, offset), randAccess);
                }
                prevEnd = offset + blockSize;
            }

        } finally {
            Util.close(randAccess);
        }

        return rangeList.getInputStream();
    }

    /**
     * Returns the assembly instructions needed by the server to relocate the
     * blocks it already has.
     * <p/>
     *
     * The
     * <code>combineRanges</code> argument determines whether contiguous
     * matching blocks should be combined into a single range, e.g. given a
     * blockSize of 100, whether 0-10/500, 10-20/600, 20-30/700 should be
     * combined into the single RelocateRange of 0-30/500.
     *
     * @param fileMap An array mapping blocks in the server file to their
     * matches in the local file
     * @param blockSize The block size used by fileMap
     * @param fileLength The length of the local file to be uploaded
     * @param combineRanges Whether consecutive matches should be combined into
     * a single RelocateRange
     * @return A list of RelocateRange instructions to be sent to the server
     * @throws IOException
     *
     */
    public static InputStream serversRelocationRanges(long[] fileMap,
            int blockSize, long fileLength, boolean combineRanges) throws IOException {

        //ArrayList<RelocateRange> ranges = new ArrayList<RelocateRange>();
        RelocWriter relocList = new RelocWriter(16384);

        for (int blockIndex = 0; blockIndex < fileMap.length; blockIndex++) {

            long localOffset = fileMap[blockIndex];
            if (localOffset >= 0 && localOffset != blockIndex * blockSize) {

                if (localOffset > fileLength - blockSize) {
                    //out of range
                    continue;
                }

                Range blockRange;
                if (combineRanges == true) {

                    //blockRange = null;
                    blockRange = consecMatches(fileMap, blockSize, blockIndex);
                    blockIndex += blockRange.getFinish() - blockRange.getStart() - 1;
                } else {

                    blockRange = new Range((long)blockIndex, blockIndex + 1L);
                }

                RelocateRange relocRange = new RelocateRange(blockRange, localOffset);
                relocList.add(relocRange);
            }
        }
        return relocList.getInputStream();
    }

    /**
     * Combines a sequence of contiguous matching blocks into a single Range
     *
     * @param fileMap The array mapping matching blocks, obtained from
     * MakeContext
     * @param blockSize The number of bytes in a block
     * @param blockIndex The index of the first block of the sequence
     * @return A Range beginning at blockIndex that is to be relocated as a
     * single chunk
     */
    private static Range consecMatches(long[] fileMap, int blockSize, int blockIndex) {

        int startBlock = blockIndex++;
        long currByte = fileMap[startBlock];

        for (; blockIndex < fileMap.length; blockIndex++) {

            if (fileMap[blockIndex] != currByte + blockSize) {

                break;
            }
            currByte += blockSize;
        }

        return new Range(startBlock, blockIndex);
    }

    /**
     * Returns the List of DataRange objects containing the portions of the
     * client file to be uploaded to the server. Currently unused.
     *
     * @param ranges The List of Ranges from the client file needed by the
     * server, which can be obtained from
     * {@link #serversMissingRanges(long[], long, int)}
     * @param local The client file to be uploaded
     * @return The List of DataRange objects containing client file portions to
     * be uploaded
     * @throws IOException
     */
    public static List<DataRange> getDataRanges(List<Range> ranges, File local) throws IOException {

        List<DataRange> dataRanges = new ArrayList<>();
        RandomAccessFile randAccess = new RandomAccessFile(local, "r");

        for (Range range : ranges) {

            dataRanges.add(new DataRange(range, randAccess));
        }

        return dataRanges;
    }

    /**
     * Returns the stream of bytes to be used as the body of a ZSync PUT.<p/>
     *
     * Note: Any temporary files used to store the data for the stream will be
     * deleted once the stream is closed, so a second invocation of this method
     * may not work.
     *
     * @return The InputStream containing the data for a ZSync PUT
     * @throws UnsupportedEncodingException
     * @throws IOException
     */
    public InputStream getInputStream() throws UnsupportedEncodingException, IOException {

        return upload.getInputStream();
    }

    /**
     * Generates the relocStream portion of an Upload from a List of
     * RelocateRanges.
     *
     * @param relocList The List of RelocateRanges
     * @return An InputStream containing the relocStream portion of an Upload
     * @throws IOException
     */
    public static InputStream getRelocStream(List<RelocateRange> relocList) throws IOException {

        RelocWriter relocWriter = new RelocWriter(16384);
        for (RelocateRange reloc : relocList) {
            relocWriter.add(reloc);
        }
        return relocWriter.getInputStream();
    }

    /**
     * Generates the dataStream portion of an Upload from the local file and a
     * List of Ranges
     *
     * @param ranges The List of byte ranges
     * @param local The local file being uploaded
     * @return The InputStream containing the dataStream portion of an Upload
     * @throws IOException
     */
    public static InputStream getDataStream(List<Range> ranges, File local) throws IOException {

        ByteRangeWriter dataWriter = new ByteRangeWriter(16384);
        RandomAccessFile randAccess = null;

        try {

            randAccess = new RandomAccessFile(local, "r");
            for (Range range : ranges) {
                dataWriter.add(range, randAccess);
            }
            return dataWriter.getInputStream();
        } finally {
            Util.close(randAccess);
        }
    }
}